Skip to content

07: Sub_Plugin - #7

Open
nikolaystrikhar wants to merge 4 commits into
06-conflict-policyfrom
07-sub-plugin
Open

07: Sub_Plugin#7
nikolaystrikhar wants to merge 4 commits into
06-conflict-policyfrom
07-sub-plugin

Conversation

@nikolaystrikhar

Copy link
Copy Markdown
Contributor

What: Sub_Plugin — one registered sub-plugin, its configuration, and every per-sub-plugin decision. The plan calls this the largest genuine review surface in the project, and the review bore that out.

Stacked on #6.

Why this way: every predicate is a plain method taking no hooks, so the collaborators in later PRs stay thin and each decision is testable directly.

The review found six real defects in the design as planned. Each one is a config mistake a host could plausibly make, and in every case the old behaviour failed silently or destructively.

is_callable() was the type discriminator on conflict_policy, enabled, and both message keys. It returns true for any string naming an existing function — and conflict_policy is explicitly designed to be readable from an option (Conflict_Policy::is_valid() says so in its own docblock). A stored value of date, flush, or key was therefore invoked rather than used: an uncaught TypeError at plugins_loaded on PHP 8 — white screen on every request, including the dashboard you would use to fix it — and a silent empty policy on 7.4. A version-dependent split across the two legs CI actually tests. Strings and bools are values now; only real callables get called.

An uncallable dependency_check reported dependencies met. is_callable( $check ) ? (bool) $check() : true cannot distinguish "not configured" from "configured but not invocable", and both answered true. Write [ $this, 'has_woocommerce' ] where the method is private, or typo a function name, and the check silently vanishes — the bundled plugin loads and fatals on the very dependency the key exists to guard, with no notice queued to explain it. Both callable-only keys are now validated at registration.

Required keys were checked with empty() but never type-checked. An array survives that and casts to the string "Array" — so every sub-plugin with the same mistake collapses onto one registry key, one activation-tracking key, and one notice id, overwriting each other. Now they must be non-empty strings.

The network check was dead code. Verified against the WordPress install: is_plugin_active() already ORs in is_plugin_active_for_network(). The OR cost a second get_site_option() per sub-plugin per request, and the test defending it stubbed is_plugin_active => false, is_plugin_active_for_network => true — a state WordPress cannot produce. Both are fixed; that test now describes reality, and a new test asserts the basename actually reaches WordPress, which nothing checked before (three later PRs pass that same string to deactivate_plugins()).

load_plugin_functions() guarded on the wrong function. Both live in wp-admin/includes/plugin.php, so it worked by coincidence — but if ( ! function_exists( 'is_plugin_active' ) ) is a common third-party shim, and one would short-circuit the require and leave is_plugin_active_for_network() undefined. It guards on the network function now.

get_conflict_notice_message() had no fallback, and Task 14 has none either. A host that never configured a message would have been shown WordPress's raw "triggered a fatal error" screen — precisely what that rewrite exists to prevent. It takes a $default now, and the plan is updated so Task 14 passes one.

Also: is_scalar() guards the filter result, since a filter returning WP_Error would have been a fatal on cast; every configured callable now receives the Sub_Plugin, which conflict_policy did and enabled did not; and the README documents the conflict_policy filter (public API this PR introduces, previously undocumented), that the guard constant must be defined at file scope, and that the activation-request fatal is caught, not prevented.

One plan defect hit while implementing: the fixture helper was named make(), which collides with Codeception\Test\Unit::make(). Declared private, that is a fatal at class-compile time — the suite does not fail, it fails to start. Renamed to make_sub_plugin().

Verify: slic run unit — 77 tests, 111 assertions, green. composer test:analysis[OK] No errors, exit 0.

- is_callable() was the type discriminator on every string-or-callable key,
  and it is true for any string naming an existing function. conflict_policy
  is designed to be readable from an option, so a stored 'date' or 'flush'
  was invoked instead of used: a TypeError at plugins_loaded on PHP 8, a
  silent empty policy on 7.4. Strings and bools are values now.
- Type-check required keys. An array passed the truthiness check and cast to
  "Array", which every sub-plugin making that mistake would have shared as
  its registry key, activation key, and notice id.
- Reject an uncallable dependency_check or activation_callback at
  registration. Read-time is_callable() made "not configured" and
  "configured but uncallable" indistinguishable, so a dependency_check
  pointing at a private method reported dependencies met and let the load
  run into the fatal it guards.
- Drop the redundant network check: WordPress's is_plugin_active() already
  ORs it in. The test that pinned it described a state WordPress cannot
  produce.
- Guard the plugin.php require on is_plugin_active_for_network, since
  is_plugin_active is a function third parties shim.
- Give get_conflict_notice_message() a default, guard the filter result with
  is_scalar(), and pass the Sub_Plugin to every configured callable.
@nikolaystrikhar nikolaystrikhar mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant